package jmine.tec.json;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.math.BigDecimal;
import java.text.ParseException;

import org.apache.commons.lang.StringEscapeUtils;

/**
 * Parser para {@link JSON}
 * 
 * @author takeshi
 */
public final class JSONParser {

    private static final int TRUE_ARRAY_SIZE = 4;

    private static final int FALSE_ARRAY_SIZE = 5;

    /**
     * Nao pode ser instanciado
     */
    private JSONParser() {
    }

    /**
     * Tenta fazer o parse a partir de uma String
     * 
     * @param source String
     * @return JSON
     * @throws ParseException e
     */
    public static JSON fromString(String source) throws ParseException {
        try {
            return fromReader(new StringReader(source));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Tenta fazer o parse a partir de um Reader
     * 
     * @param reader Reader
     * @return JSON
     * @throws IOException e
     * @throws ParseException e
     */
    public static JSON fromReader(Reader reader) throws IOException, ParseException {
        Reader bufReader = new BufferedReader(reader);
        try {
            return nextJSON(new PushBackReader(bufReader));
        } finally {
            bufReader.close();
        }
    }

    /**
     * Tenta ler um JSON qualquer
     * 
     * @param bufReader {@link PushBackReader}
     * @return JSON
     * @throws IOException e
     * @throws ParseException e
     */
    private static JSON nextJSON(PushBackReader bufReader) throws IOException, ParseException {
        int c = consumeBlanks(bufReader);
        if (c == -1) {
            return JSONNullObject.INSTANCE;
        }
        switch (c) {
        case '\"':
            final JSONString jsonString = new JSONString(readUntil('\"', bufReader));
            if (bufReader.read() != '\"') {
                throw new ParseException("unterminated string literal: " + jsonString.getString(), bufReader.getPosition());
            }
            return jsonString;
        case '[':
            return readJSONArray(bufReader);
        case '{':
            return readJSONObject(bufReader);
        default:
            bufReader.push((char) c);
            return readJSONLiteral(bufReader);
        }
    }

    /**
     * Devolve true se eh um isoDigit
     * 
     * @param c character
     * @return boolean
     */
    private static boolean isISODigit(char c) {
        return (c >= '0' && c <= '9') || c == '.' || c == '-';
    }

    /**
     * Le um literal JSON - ou um numero ou um boolean
     * 
     * @param reader {@link PushBackReader}
     * @return JSON
     * @throws IOException e
     * @throws ParseException e
     */
    private static JSON readJSONLiteral(PushBackReader reader) throws IOException, ParseException {
        int newChar = consumeBlanks(reader);
        if (Character.isDigit((char) newChar) || '-' == newChar || '+' == newChar) {
            StringBuilder sb = new StringBuilder();
            int cc = newChar;
            while (cc >= 0 && isISODigit((char) cc)) {
                sb.append((char) cc);
                cc = reader.read();
            }
            if (cc == 'e' || cc == 'E') {
                sb.append('e');
                // parse the rest of the number
                cc = reader.read();
                while (cc >= 0 && isISODigit((char) cc)) {
                    sb.append((char) cc);
                    cc = reader.read();
                }
            }
            if (cc >= 0) {
                reader.push((char) cc);
            }
            return new JSONNumber(new BigDecimal(sb.toString()));
        } else if (newChar == 't' || newChar == 'T' || newChar == 'f' || newChar == 'F') {
            // either true or false
            final char[] array;
            if (newChar == 't' || newChar == 'T') {
                array = new char[TRUE_ARRAY_SIZE];
                array[0] = (char) newChar;
                final int expected = 3;
                int amount = reader.read(array, 1, expected);
                if (amount != expected) {
                    throw new ParseException("Unexpected end of file reached", reader.getPosition());
                }
            } else {
                array = new char[FALSE_ARRAY_SIZE];
                array[0] = (char) newChar;
                final int expected = 4;
                int amount = reader.read(array, 1, expected);
                if (amount != expected) {
                    throw new ParseException("Unexpected end of file reached", reader.getPosition());
                }
            }
            boolean value = Boolean.parseBoolean(new String(array));
            return JSONBoolean.fromBoolean(value);
        } else if (newChar == 'n') {
            final char[] array = new char[FALSE_ARRAY_SIZE];
            array[0] = (char) newChar;
            final int expected = 3;
            int amount = reader.read(array, 1, expected);
            if (amount != expected) {
                throw new ParseException("Unexpected end of file reached", reader.getPosition());
            }
            return JSONNullObject.INSTANCE;
            // should be a null thing
        } else {
            throw new ParseException("Unexpected start of literal: " + ((char) newChar), reader.getPosition());
        }
    }

    /**
     * Le um JSONArray - que nada mais eh do que uma List de outros JSON
     * 
     * @param reader {@link PushBackReader}
     * @return {@link JSONArray}
     * @throws IOException e
     * @throws ParseException e
     */
    private static JSONArray readJSONArray(PushBackReader reader) throws IOException, ParseException {
        JSONArray array = new JSONArray();
        while (true) {
            int r = consumeBlanks(reader);
            if (r == -1 || r == ']') {
                break;
            } else {
                reader.push((char) r);
                JSON next = nextJSON(reader);
                array.add(next);
                final int consumeBlanks = consumeBlanks(reader);
                if (consumeBlanks != ',') {
                    if (consumeBlanks == ']') {
                        break;
                    }
                    throw new ParseException("expected end of array or next element, but found: " + ((char) consumeBlanks),
                            reader.getPosition());
                }
            }
        }
        return array;
    }

    /**
     * Le um {@link JSONObject} - que nada mais eh do que um Map de String para objetos JSON
     * 
     * @param reader {@link PushBackReader}
     * @return {@link JSONObject}
     * @throws IOException e
     * @throws ParseException e
     */
    private static JSONObject readJSONObject(PushBackReader reader) throws IOException, ParseException {
        JSONObject object = new JSONObject();
        while (true) {
            final int notBlank = consumeBlanks(reader);
            int r = notBlank;
            if (r == -1 || r == '}') {
                break;
            } else if (r == '\"') {
                String key = readUntil('\"', reader);
                if (reader.read() != '\"') {
                    throw new ParseException("unterminated string literal: " + key, reader.getPosition());
                }
                int delimiter = consumeBlanks(reader);
                if (delimiter != ':') {
                    throw new ParseException("expected ':' but was '" + ((char) delimiter), reader.getPosition());
                }
                JSON val = nextJSON(reader);
                object.setProperty(key, val);
                delimiter = consumeBlanks(reader);
                if (delimiter != ',') {
                    break;
                }
            }
        }
        return object;
    }

    /**
     * Consome espacos em branco, incluindo \t e \n
     * 
     * @param reader {@link PushBackReader}
     * @return int o ultimo char lido que nao eh um espaco em branco
     * @throws IOException e
     */
    private static int consumeBlanks(PushBackReader reader) throws IOException {
        int read = reader.read();
        while (read != -1 && isBlank((char) read)) {
            read = reader.read();
        }
        return read;
    }

    /**
     * Verifica se eh um espaco em branco
     * 
     * @param c c
     * @return boolean
     */
    private static boolean isBlank(char c) {
        return c == ' ' || c == '\t' || c == '\n' || c == '\r'; // , is a delimiter, so we can ignore
    }

    /**
     * Le a string ateh encontrar o caracter c
     * 
     * @param c char
     * @param reader {@link PushBackReader}
     * @return String
     * @throws IOException e
     */
    private static String readUntil(char c, PushBackReader reader) throws IOException {
        StringBuilder builder = new StringBuilder();
        int read = reader.read();
        boolean escaped = false;
        while (read >= 0) {
            if (escaped) {
                escaped = false;
                builder.append((char) read);
            } else if (read == c) {
                reader.push(c);
                break;
            } else if (read == '\\') {
                escaped = true;
                builder.append((char) read);
            } else {
                builder.append((char) read);
            }
            read = reader.read();
        }
        return StringEscapeUtils.unescapeJavaScript(builder.toString());
    }

    /**
     * Reader com um buffer de 1 caracter
     * 
     * @author takeshi
     */
    private static class PushBackReader extends Reader {

        private final Reader delegate;

        private int buffer = -1;

        private int position = -1;

        /**
         * @param delegate Reader
         */
        public PushBackReader(Reader delegate) {
            super();
            this.delegate = delegate;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void close() throws IOException {
            this.delegate.close();
        }

        /**
         * Empurra um caracter no buffer
         * 
         * @param c char
         */
        public void push(char c) {
            this.position--;
            this.buffer = c;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public int read() throws IOException {
            this.position++;
            if (this.buffer != -1) {
                char read = (char) this.buffer;
                this.buffer = -1;
                return read;
            }
            return this.delegate.read();
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public int read(char[] cbuf, int off, int len) throws IOException {
            int counter = 0;
            for (int i = off; i < len + off; i++) {
                final int lastRead = this.read();
                if (lastRead == -1) {
                    this.position += counter;
                    return counter;
                }
                cbuf[i] = (char) lastRead;
                counter++;
            }
            this.position += counter;
            return counter;
        }

        /**
         * @return the position
         */
        public int getPosition() {
            return this.position;
        }

    }

}
